Correctly attempt multiple usernames with libgit2
authorAlex Crichton <alex@alexcrichton.com>
Sat, 16 Apr 2016 00:45:30 +0000 (17:45 -0700)
committerAlex Crichton <alex@alexcrichton.com>
Mon, 18 Apr 2016 04:52:48 +0000 (21:52 -0700)
This commit corrects the logic for attempting multiple usernames with libgit2.
There is a restriction that for each authentication seession you can only
authenticate with one ssh username, but we were attempting multiple as part of
the same sesssion. Instead restart authentication whenever we try a new username.

Closes #2399

src/cargo/sources/git/utils.rs
tests/test_cargo_build_auth.rs

index d865262d080c28c99aa74dd86075b5f337e6668c..091529333fc917604a1950de5be5aede53c0dd83 100644 (file)
@@ -388,29 +388,18 @@ impl<'a> GitCheckout<'a> {
 /// attempted and we don't try the same ones again.
 fn with_authentication<T, F>(url: &str, cfg: &git2::Config, mut f: F)
                              -> CargoResult<T>
-    where F: FnMut(&mut git2::Credentials) -> CargoResult<T>
+    where F: FnMut(&mut git2::Credentials) -> Result<T, git2::Error>
 {
     let mut cred_helper = git2::CredentialHelper::new(url);
     cred_helper.config(cfg);
 
-    let mut attempted = git2::CredentialType::empty();
-    let mut failed_cred_helper = false;
-
-    // We try a couple of different user names when cloning via ssh as there's a
-    // few possibilities if one isn't mentioned, and these are used to keep
-    // track of that.
-    enum UsernameAttempt {
-        Arg,
-        CredHelper,
-        Local,
-        Git,
-    }
-    let mut username_attempt = UsernameAttempt::Arg;
-    let mut username_attempts = Vec::new();
-
-    let res = f(&mut |url, username, allowed| {
-        let allowed = allowed & !attempted;
+    let mut ssh_username_requested = false;
+    let mut cred_helper_bad = None;
+    let mut ssh_agent_attempts = Vec::new();
+    let mut any_attempts = false;
 
+    let mut res = f(&mut |url, username, allowed| {
+        any_attempts = true;
         // libgit2's "USERNAME" authentication actually means that it's just
         // asking us for a username to keep going. This is currently only really
         // used for SSH authentication and isn't really an authentication type.
@@ -422,11 +411,17 @@ fn with_authentication<T, F>(url: &str, cfg: &git2::Config, mut f: F)
         //
         //      callback(SSH_KEY, user, ...)
         //
-        // So if we have a USERNAME request we just pass it either `username` or
-        // a fallback of "git". We'll do some more principled attempts later on.
+        // So if we're being called here then we know that (a) we're using ssh
+        // authentication and (b) no username was specified in the URL that
+        // we're trying to clone. We need to guess an appropriate username here,
+        // but that may involve a few attempts. Unfortunately we can't switch
+        // usernames during one authentication session with libgit2, so to
+        // handle this we bail out of this authentication session after setting
+        // the flag `ssh_username_requested`, and then we handle this below.
         if allowed.contains(git2::USERNAME) {
-            attempted = attempted | git2::USERNAME;
-            return git2::Cred::username(username.unwrap_or("git"))
+            debug_assert!(username.is_none());
+            ssh_username_requested = true;
+            return Err(git2::Error::from_str("gonna try usernames later"))
         }
 
         // An "SSH_KEY" authentication indicates that we need some sort of SSH
@@ -434,39 +429,14 @@ fn with_authentication<T, F>(url: &str, cfg: &git2::Config, mut f: F)
         // process or from a raw in-memory SSH key. Cargo only supports using
         // ssh-agent currently.
         //
-        // We try a few different usernames here, including:
-        //
-        //  1. The `username` argument, if provided. This will cover cases where
-        //     the user was passed in the URL, for example.
-        //  2. The global credential helper's username, if any is configured
-        //  3. The local account's username (if present)
-        //  4. Finally, "git" as it's a common fallback (e.g. with github)
+        // If we get called with this then the only way that should be possible
+        // is if a username is specified in the URL itself (e.g. `username` is
+        // Some), hence the unwrap() here. We try custom usernames down below.
         if allowed.contains(git2::SSH_KEY) {
-            loop {
-                let name = match username_attempt {
-                    UsernameAttempt::Arg => {
-                        username_attempt = UsernameAttempt::CredHelper;
-                        username.map(|s| s.to_string())
-                    }
-                    UsernameAttempt::CredHelper => {
-                        username_attempt = UsernameAttempt::Local;
-                        cred_helper.username.clone()
-                    }
-                    UsernameAttempt::Local => {
-                        username_attempt = UsernameAttempt::Git;
-                        env::var("USER").or_else(|_| env::var("USERNAME")).ok()
-                    }
-                    UsernameAttempt::Git => {
-                        attempted = attempted | git2::SSH_KEY;
-                        Some("git".to_string())
-                    }
-                };
-                if let Some(name) = name {
-                    let ret = git2::Cred::ssh_key_from_agent(&name);
-                    username_attempts.push(name);
-                    return ret
-                }
-            }
+            let username = username.unwrap();
+            debug_assert!(!ssh_username_requested);
+            ssh_agent_attempts.push(username.to_string());
+            return git2::Cred::ssh_key_from_agent(&username)
         }
 
         // Sometimes libgit2 will ask for a username/password in plaintext. This
@@ -475,16 +445,14 @@ fn with_authentication<T, F>(url: &str, cfg: &git2::Config, mut f: F)
         // plaintext password is through the `credential.helper` support, so
         // fetch that here.
         if allowed.contains(git2::USER_PASS_PLAINTEXT) {
-            attempted = attempted | git2::USER_PASS_PLAINTEXT;
             let r = git2::Cred::credential_helper(cfg, url, username);
-            failed_cred_helper = r.is_err();
+            cred_helper_bad = Some(r.is_err());
             return r
         }
 
         // I'm... not sure what the DEFAULT kind of authentication is, but seems
         // easy to support?
         if allowed.contains(git2::DEFAULT) {
-            attempted = attempted | git2::DEFAULT;
             return git2::Cred::default()
         }
 
@@ -492,8 +460,69 @@ fn with_authentication<T, F>(url: &str, cfg: &git2::Config, mut f: F)
         Err(git2::Error::from_str("no authentication available"))
     });
 
-    if attempted.bits() == 0 || res.is_ok() {
-        return res
+    // Ok, so if it looks like we're going to be doing ssh authentication, we
+    // want to try a few different usernames as one wasn't specified in the URL
+    // for us to use. In order, we'll try:
+    //
+    // * A credential helper's username for this URL, if available.
+    // * This account's username.
+    // * "git"
+    //
+    // We have to restart the authentication session each time (due to
+    // constraints in libssh2 I guess? maybe this is inherent to ssh?), so we
+    // call our callback, `f`, in a loop here.
+    if ssh_username_requested {
+        debug_assert!(res.is_err());
+        let mut attempts = Vec::new();
+        attempts.push("git".to_string());
+        if let Ok(s) = env::var("USER").or_else(|_| env::var("USERNAME")) {
+            attempts.push(s);
+        }
+        if let Some(ref s) = cred_helper.username {
+            attempts.push(s.clone());
+        }
+
+        while let Some(s) = attempts.pop() {
+            // We should get `USERNAME` first, where we just return our attempt,
+            // and then after that we should get `SSH_KEY`. If the first attempt
+            // fails we'll get called again, but we don't have another option so
+            // we bail out.
+            let mut attempts = 0;
+            res = f(&mut |_url, username, allowed| {
+                if allowed.contains(git2::USERNAME) {
+                    return git2::Cred::username(&s);
+                }
+                if allowed.contains(git2::SSH_KEY) {
+                    debug_assert_eq!(Some(&s[..]), username);
+                    attempts += 1;
+                    if attempts == 1 {
+                        ssh_agent_attempts.push(s.to_string());
+                        return git2::Cred::ssh_key_from_agent(&s)
+                    }
+                }
+                Err(git2::Error::from_str("no authentication available"))
+            });
+
+            // If we made two attempts then that means:
+            //
+            // 1. A username was requested, we returned `s`.
+            // 2. An ssh key was requested, we returned to look up `s` in the
+            //    ssh agent.
+            // 3. For whatever reason that lookup failed, so we were asked again
+            //    for another mode of authentication.
+            //
+            // Essentially, if `attempts == 2` then in theory the only error was
+            // that this username failed to authenticate (e.g. no other network
+            // errors happened). Otherwise something else is funny so we bail
+            // out.
+            if attempts != 2 {
+                break
+            }
+        }
+    }
+
+    if res.is_ok() || !any_attempts {
+        return res.map_err(From::from)
     }
 
     // In the case of an authentication failure (where we tried something) then
@@ -502,15 +531,15 @@ fn with_authentication<T, F>(url: &str, cfg: &git2::Config, mut f: F)
     res.chain_error(|| {
         let mut msg = "failed to authenticate when downloading \
                        repository".to_string();
-        if attempted.contains(git2::SSH_KEY) {
-            let names = username_attempts.iter()
-                                         .map(|s| format!("`{}`", s))
-                                         .collect::<Vec<_>>()
-                                         .join(", ");
+        if ssh_agent_attempts.len() > 0 {
+            let names = ssh_agent_attempts.iter()
+                                          .map(|s| format!("`{}`", s))
+                                          .collect::<Vec<_>>()
+                                          .join(", ");
             msg.push_str(&format!("\nattempted ssh-agent authentication, but \
                                    none of the usernames {} succeeded", names));
         }
-        if attempted.contains(git2::USER_PASS_PLAINTEXT) {
+        if let Some(failed_cred_helper) = cred_helper_bad {
             if failed_cred_helper {
                 msg.push_str("\nattempted to find username/password via \
                               git's `credential.helper` support, but failed");
index e8e9ac59150be7aa94c3a7b541190aa98e7fa97f..d3028c5befab772af7fdeb1c74c6e5effd09b6fe 100644 (file)
@@ -145,17 +145,10 @@ test!(https_something_happens {
         updating = UPDATING,
         addr = addr,
         ))
-                      .with_stderr(&format!("\
-{error} Unable to update https://{addr}/foo/bar
-
-Caused by:
-  failed to clone into: [..]
-
+                      .with_stderr_contains(&format!("\
 Caused by:
   {errmsg}
 ",
-        addr = addr,
-        error = ERROR,
         errmsg = if cfg!(windows) {
             "[[..]] failed to send request: [..]\n"
         } else if cfg!(target_os = "macos") {
@@ -197,16 +190,9 @@ test!(ssh_something_happens {
         updating = UPDATING,
         addr = addr,
         ))
-                      .with_stderr(&format!("\
-{error} Unable to update ssh://{addr}/foo/bar
-
-Caused by:
-  failed to clone into: [..]
-
+                      .with_stderr_contains("\
 Caused by:
   [[..]] Failed to start SSH session: Failed getting banner
-",
-        addr = addr,
-        error = ERROR)));
+"));
     t.join().ok().unwrap();
 });